package com.yc.fedex;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.util.JacksonFeature;
import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider;
import com.yc.fedex.exceptions.FedexErrorResponseException;
import com.yc.fedex.mapper.FedexJsonMapper;
import com.yc.fedex.model.enums.FedexAuthorizationGrantTypeEnum;
import com.yc.fedex.model.request.rate.RateAndTransitTimesRequest;
import com.yc.fedex.model.request.ship.CancelShipmentRequest;
import com.yc.fedex.model.request.ship.CreateShipmentRequest;
import com.yc.fedex.model.request.track.*;
import com.yc.fedex.model.response.FedexAccessToken;
import com.yc.fedex.model.response.FedexBaseResponse;
import com.yc.fedex.model.response.file.DocumentResponse;
import com.yc.fedex.model.response.rate.RateAndTransitTimesResponse;
import com.yc.fedex.model.response.ship.CancelShipmentResponse;
import com.yc.fedex.model.response.ship.CreateShipmentResponse;
import com.yc.fedex.model.response.track.TrackByReferencesResponse;
import com.yc.fedex.model.response.track.TrackDocumentResponse;
import com.yc.fedex.model.response.track.TrackShipmentResponse;
import lombok.extern.slf4j.Slf4j;
import net.jodah.failsafe.Failsafe;
import net.jodah.failsafe.RetryPolicy;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.media.multipart.FormDataMultiPart;
import org.glassfish.jersey.media.multipart.MultiPartFeature;
import org.glassfish.jersey.media.multipart.file.StreamDataBodyPart;
import org.glassfish.jersey.message.GZipEncoder;

import javax.ws.rs.client.*;
import javax.ws.rs.core.Form;
import javax.ws.rs.core.GenericType;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.InputStream;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
public class FedexSdk {

    public static final String SANDBOX = "apis-sandbox";

    private static final String HTTPS = "https://";

    private static final String SUB_DOMAIN = ".fedex.com";

    public static final String SANDBOX_DOMAIN = HTTPS + "apis-sandbox" + SUB_DOMAIN;

    public static final String DOMAIN = HTTPS + "apis" + SUB_DOMAIN;

    private static final String DOCUMENT_SANDBOX_DOMAIN = "https://documentapitest.prod.fedex.com/sandbox";

    private static final String DOCUMENT_DOMAIN = "https://documentapitest.prod.fedex.com";

    public static final String LETTERHEAD = "{\"document\":{\"referenceId\":\"letterhead\",\"name\":\"letterhead.png\",\"contentType\":\"image/png\",\"meta\":{\"imageType\":\"LETTERHEAD\",\"imageIndex\":\"IMAGE_1\"}},\"rules\":{\"workflowName\":\"LetterheadSignature\"}}";

    public static final String SIGNATURE = "{\"document\":{\"referenceId\":\"signature\",\"name\":\"signature.png\",\"contentType\":\"image/png\",\"meta\":{\"imageType\":\"SIGNATURE\",\"imageIndex\":\"IMAGE_2\"}},\"rules\":{\"workflowName\":\"LetterheadSignature\"}}";

    public static final String COMMERCIAL_INVOICE = "{\"workflowName\":\"ETDPreshipment\",\"carrierCode\":\"FDXE\",\"name\":\"etd.xlsx\",\"contentType\":\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"meta\":{\"shipDocumentType\":\"COMMERCIAL_INVOICE\",\"formCode\":\"USMCA\",\"originCountryCode\":\"US\",\"destinationCountryCode\":\"CN\"}}";

    static final String RETRY_AFTER_HEADER = "Retry-After";
    static final String OAUTH = "oauth";

    static final String AUTHORIZATION = "authorization";

    static final String V1_VERSION = "v1";

    static final String API_TOKEN = "token";

    static final String TRACK = "track";

    static final String RATE = "rate";

    static final String RATES = "rates";

    static final String QUOTES = "quotes";

    static final String SHIP = "ship";

    static final String SHIPMENTS = "shipments";

    static final String CANCEL = "cancel";

    static final String DOCUMENTS = "documents";

    static final String ASSOCIATED_SHIPMENTS = "associatedshipments";

    static final String REFERENCE_NUMBERS = "referencenumbers";

    static final String TCN = "tcn";

    static final String TRACKING_NUMBERS = "trackingnumbers";

    static final String TRACKING_DOCUMENTS = "trackingdocuments";

    private static final long TWO_HUNDRED_MILLISECONDS = 200L;
    private static final String MINIMUM_REQUEST_RETRY_DELAY_CANNOT_BE_LARGER_THAN_MAXIMUM_REQUEST_RETRY_DELAY_MESSAGE = "Maximum request retry delay must be larger than minimum request retry delay.";
    private static final String INVALID_MINIMUM_REQUEST_RETRY_DELAY_MESSAGE = "Minimum request retry delay cannot be set lower than 200 milliseconds.";

    private static final Long DEFAULT_MAXIMUM_REQUEST_RETRY_RANDOM_DELAY_IN_MILLISECONDS = 5000L;
    private static final Long DEFAULT_MINIMUM_REQUEST_RETRY_RANDOM_DELAY_IN_MILLISECONDS = 1000L;
    private static final long DEFAULT_READ_TIMEOUT_IN_MILLISECONDS = 15000L;
    private static final long DEFAULT_CONNECTION_TIMEOUT_IN_MILLISECONDS = 60000L;

    private final Client CLIENT = ClientBuilder.newClient().register(JacksonFeature.class).register(new JacksonJaxbJsonProvider(FedexJsonMapper.buildMapper(), JacksonJaxbJsonProvider.DEFAULT_ANNOTATIONS)).register(MultiPartFeature.class).register(GZipEncoder.class);

    private String domain;
    private String documentDomain;
    private String appKey;
    private String appSecret;
    private String accessToken;
    private FedexAccessToken accessTokeInfo;
    private LocalDateTime AccessTokenExpiresTime;
    private long minimumRequestRetryRandomDelayMilliseconds;
    private long maximumRequestRetryRandomDelayMilliseconds;

    public static DomainStep oauth() {
        return new Steps();
    }

    public interface DomainStep {
        AppKeyStep withDomain(String domain);
    }

    public interface AppKeyStep {
        AppSecretKeyStep withAppKey(String appKey);
    }

    public interface AppSecretKeyStep {

        OptionalsStep withAppSecret(String appSecretKey);
    }

    public interface RedirectUrlStep {

        ScopesStep withRedirectUrl(String redirectUrl);
    }

    public interface ScopesStep {

        OptionalsStep withScopes(String scopes);
    }

    public interface OptionalsStep {

        /**
         * The Shopify SDK uses random waits in between retry attempts. Minimum duration
         * time to wait before retrying a failed request. Value must also be less than
         * {@link #withMaximumRequestRetryRandomDelay(int, TimeUnit) Maximum Request
         * Retry Random Delay}.<br>
         * Default value is: 1 second.
         *
         * @param duration
         * @param timeUnit
         * @return {@link OptionalsStep}
         */
        OptionalsStep withMinimumRequestRetryRandomDelay(int duration, TimeUnit timeUnit);

        /**
         * The Shopify SDK uses random waits in between retry attempts. Maximum duration
         * time to wait before retrying a failed request. Value must also be more than
         * {@link #withMinimumRequestRetryRandomDelay(int, TimeUnit) Minimum Request
         * Retry Random Delay}.<br>
         * Default value is: 5 seconds.
         *
         * @param duration
         * @param timeUnit
         * @return {@link OptionalsStep}
         */
        OptionalsStep withMaximumRequestRetryRandomDelay(int duration, TimeUnit timeUnit);

        /**
         * The duration to wait when connecting to wix's API. <br>
         * Default value is: 1 minute.
         *
         * @param duration
         * @param timeUnit
         * @return {@link OptionalsStep}
         */
        OptionalsStep withConnectionTimeout(int duration, TimeUnit timeUnit);

        /**
         * The duration to attempt to read a response from wix's API. <br>
         * Default value is: 15 seconds.
         *
         * @param duration
         * @param timeUnit
         * @return {@link OptionalsStep}
         */
        OptionalsStep withReadTimeout(int duration, TimeUnit timeUnit);


        FedexSdk build();

    }

    protected FedexSdk(final Steps steps) {
        if (steps != null) {
            this.domain = steps.domain;
            this.documentDomain = steps.documentDomain;
            this.appKey = steps.appKey;
            this.appSecret = steps.appSecret;
            this.minimumRequestRetryRandomDelayMilliseconds = steps.minimumRequestRetryRandomDelayMilliseconds;
            this.maximumRequestRetryRandomDelayMilliseconds = steps.maximumRequestRetryRandomDelayMilliseconds;
            CLIENT.property(ClientProperties.CONNECT_TIMEOUT, Math.toIntExact(steps.connectionTimeoutMilliseconds));
            CLIENT.property(ClientProperties.READ_TIMEOUT, Math.toIntExact(steps.readTimeoutMilliseconds));
            validateConstructionSdk();
            getAccessToken();
        }
    }

    private void validateConstructionSdk() {
        if (this.minimumRequestRetryRandomDelayMilliseconds < TWO_HUNDRED_MILLISECONDS) {
            throw new IllegalArgumentException(INVALID_MINIMUM_REQUEST_RETRY_DELAY_MESSAGE);
        }
        if (minimumRequestRetryRandomDelayMilliseconds > maximumRequestRetryRandomDelayMilliseconds) {
            throw new IllegalArgumentException(
                    MINIMUM_REQUEST_RETRY_DELAY_CANNOT_BE_LARGER_THAN_MAXIMUM_REQUEST_RETRY_DELAY_MESSAGE);
        }
    }

    protected static class Steps
            implements DomainStep, AppKeyStep, AppSecretKeyStep, OptionalsStep {

        private String domain;
        private String documentDomain;
        private String appKey;
        private String appSecret;
        private long minimumRequestRetryRandomDelayMilliseconds = DEFAULT_MINIMUM_REQUEST_RETRY_RANDOM_DELAY_IN_MILLISECONDS;
        private long maximumRequestRetryRandomDelayMilliseconds = DEFAULT_MAXIMUM_REQUEST_RETRY_RANDOM_DELAY_IN_MILLISECONDS;
        private long connectionTimeoutMilliseconds = DEFAULT_CONNECTION_TIMEOUT_IN_MILLISECONDS;
        private long readTimeoutMilliseconds = DEFAULT_READ_TIMEOUT_IN_MILLISECONDS;

        @Override
        public FedexSdk build() {
            return new FedexSdk(this);
        }

        @Override
        public AppKeyStep withDomain(String domain) {
            this.domain = domain.equals(SANDBOX) ? SANDBOX_DOMAIN : DOMAIN;
            this.documentDomain = domain.equals(SANDBOX) ? DOCUMENT_SANDBOX_DOMAIN : DOCUMENT_DOMAIN;
            return this;
        }

        @Override
        public AppSecretKeyStep withAppKey(String appKey) {
            this.appKey = appKey;
            return this;
        }

        @Override
        public OptionalsStep withAppSecret(String appSecretKey) {
            this.appSecret = appSecretKey;
            return this;
        }

        @Override
        public OptionalsStep withMinimumRequestRetryRandomDelay(final int duration, final TimeUnit timeUnit) {
            this.minimumRequestRetryRandomDelayMilliseconds = timeUnit.toMillis(duration);
            return this;
        }

        @Override
        public OptionalsStep withMaximumRequestRetryRandomDelay(final int duration, final TimeUnit timeUnit) {
            this.maximumRequestRetryRandomDelayMilliseconds = timeUnit.toMillis(duration);
            return this;
        }

        @Override
        public OptionalsStep withConnectionTimeout(final int duration, final TimeUnit timeUnit) {
            this.connectionTimeoutMilliseconds = timeUnit.toMillis(duration);
            return this;
        }

        @Override
        public OptionalsStep withReadTimeout(final int duration, final TimeUnit timeUnit) {
            this.readTimeoutMilliseconds = timeUnit.toMillis(duration);
            return this;
        }
    }

    private WebTarget getWebTarget() {
        return CLIENT.target(domain);
    }

    public String getAccessToken() {
        if (accessToken == null || LocalDateTime.now().isAfter(AccessTokenExpiresTime)) {
            Form form = new Form();
            form.param("grant_type", FedexAuthorizationGrantTypeEnum.client_credentials.name());
            form.param("client_id", appKey);
            form.param("client_secret", appSecret);
            FedexAccessToken fedexAccessToken = post(getWebTarget().path(OAUTH).path(API_TOKEN), form, FedexAccessToken.class);
            this.accessTokeInfo = fedexAccessToken;
            this.AccessTokenExpiresTime = LocalDateTime.now().plusSeconds(fedexAccessToken.getExpires_in() - 600);
            this.accessToken = fedexAccessToken.getAccess_token();
            return "Bearer " + fedexAccessToken.getAccess_token();
        } else {
            return "Bearer " + accessTokeInfo.getAccess_token();
        }
    }

    public FedexBaseResponse<TrackShipmentResponse> trackMultiplePieceShipment(TrackMultiplePieceShipmentRequest request) {
        return post(getWebTarget().path(TRACK).path(V1_VERSION).path(ASSOCIATED_SHIPMENTS), request, new GenericType<>() {
        });
    }

    public FedexBaseResponse<TrackByReferencesResponse> trackByReferences(TrackByReferencesRequest request) {
        return post(getWebTarget().path(TRACK).path(V1_VERSION).path(REFERENCE_NUMBERS), request, new GenericType<>() {
        });
    }

    public FedexBaseResponse<TrackDocumentResponse> trackDocument(TrackDocumentRequest request) {
        return post(getWebTarget().path(TRACK).path(V1_VERSION).path(TRACKING_DOCUMENTS), request, new GenericType<>() {
        });
    }

    public FedexBaseResponse<TrackShipmentResponse> trackByTrackingControlNumber(TrackByTrackingControlNumberRequest request) {
        return post(getWebTarget().path(TRACK).path(V1_VERSION).path(TCN), request, new GenericType<>() {
        });
    }

    public FedexBaseResponse<TrackShipmentResponse> trackByTrackingNumber(TrackByTrackingNumberRequest request) {
        return post(getWebTarget().path(TRACK).path(V1_VERSION).path(TRACKING_NUMBERS), request, new GenericType<>() {
        });
    }

    public FedexBaseResponse<RateAndTransitTimesResponse> rateAndTransitTimes(RateAndTransitTimesRequest request) throws JsonProcessingException {
        return post(getWebTarget().path(RATE).path(V1_VERSION).path(RATES).path(QUOTES), request, new GenericType<>() {
        });

    }

    public FedexBaseResponse<CreateShipmentResponse> createShipment(CreateShipmentRequest request) {
        return post(getWebTarget().path(SHIP).path(V1_VERSION).path(SHIPMENTS), request, new GenericType<>() {
        });
    }

    public FedexBaseResponse<CancelShipmentResponse> cancelShipment(CancelShipmentRequest request) {
        return put(getWebTarget().path(SHIP).path(V1_VERSION).path(SHIPMENTS).path(CANCEL), request, new GenericType<>() {
        });
    }

    public FedexBaseResponse<DocumentResponse> uploadDocument(InputStream file, String document, String fileName) {
        return post(CLIENT.target(documentDomain).path(DOCUMENTS).path(V1_VERSION).path("etds").path("upload"), file, fileName, document, new GenericType<>() {
        });
    }

    public FedexBaseResponse<DocumentResponse> uploadImages(InputStream file, String document) {
        return post(CLIENT.target(documentDomain).path(DOCUMENTS).path(V1_VERSION).path("lhsimages").path("upload"), file, "file.png", document, new GenericType<>() {
        });
    }

    private Response get(final WebTarget webTarget) {
        final Callable<Response> responseCallable = () -> oauthRequestPack(webTarget).get();
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED);
    }

    private Response get(final WebTarget webTarget, Map<String, Object> queryParams) {
        queryParamsPack(webTarget, queryParams);
        return get(webTarget);
    }

    private <T> Response get(final WebTarget webTarget, T bodyParams) {
        final Callable<Response> responseCallable = () -> {
            final Entity<T> entity = Entity.entity(bodyParams, MediaType.APPLICATION_JSON);
            return oauthRequestPack(webTarget).method("GET", entity);
        };

        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED);
    }

    private Response delete(final WebTarget webTarget) {
        final Callable<Response> responseCallable = () -> oauthRequestPack(webTarget).delete();
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED);
    }

    private Response delete(final WebTarget webTarget, Map<String, Object> queryParams) {
        queryParamsPack(webTarget, queryParams);
        final Callable<Response> responseCallable = () -> oauthRequestPack(webTarget).delete();
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED);
    }

    private <T> Response delete(final WebTarget webTarget, final T object) {
        final Callable<Response> responseCallable = () -> {
            final Entity<T> entity = Entity.entity(object, MediaType.APPLICATION_JSON);
            return oauthRequestPack(webTarget).method("DELETE", entity);
        };
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED);
    }

    private <T, E> FedexBaseResponse<E> post(final WebTarget webTarget, T object, GenericType<FedexBaseResponse<E>> genericType) {
        final Callable<Response> responseCallable = () -> {
            final Entity<T> entity = Entity.entity(object, MediaType.APPLICATION_JSON);
            return oauthRequestPack(webTarget).header(AUTHORIZATION, getAccessToken()).header("x-locale", "zh_CN").post(entity);
        };
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED).readEntity(genericType);
    }

    private <T, E> FedexBaseResponse<E> post(WebTarget webTarget, InputStream file, String document, String fileName, GenericType<FedexBaseResponse<E>> genericType) {
        final Callable<Response> responseCallable = () -> {
            FormDataMultiPart multiPart = new FormDataMultiPart();
            multiPart.field("document", document, MediaType.APPLICATION_JSON_TYPE);
            StreamDataBodyPart filePart = new StreamDataBodyPart("attachment", file, fileName, MediaType.APPLICATION_OCTET_STREAM_TYPE);
            multiPart.bodyPart(filePart);
            return oauthRequestPack(webTarget).header(AUTHORIZATION, getAccessToken()).header("x-locale", "zh_CN").post(Entity.entity(multiPart, MediaType.MULTIPART_FORM_DATA_TYPE));
        };
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED).readEntity(genericType);
    }

    private <T> T post(final WebTarget webTarget, Form form, Class<T> resultClass) {
        final Callable<Response> responseCallable = () -> {
            Entity<Form> entity = Entity.form(form);
            return oauthRequestPack(webTarget).post(entity);
        };
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED).readEntity(resultClass);
    }

    private <T, E> FedexBaseResponse<E> put(final WebTarget webTarget, final T object, GenericType<FedexBaseResponse<E>> genericType) {
        final Callable<Response> responseCallable = () -> {
            final Entity<T> entity = Entity.entity(object, MediaType.APPLICATION_JSON);
            return webTarget.request(MediaType.APPLICATION_JSON).header(AUTHORIZATION, getAccessToken()).put(entity);
        };
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED).readEntity(genericType);
    }

    private <T> Response patch(final WebTarget webTarget, final T object) {
        final Callable<Response> responseCallable = () -> {
            final Entity<T> entity = Entity.entity(object, MediaType.APPLICATION_JSON);
            return webTarget.request(MediaType.APPLICATION_JSON).header(AUTHORIZATION, getAccessToken()).method("PATCH", entity);
        };
        return handleResponse(invokeResponseCallable(responseCallable), Response.Status.OK, Response.Status.CREATED);
    }

    private Invocation.Builder oauthRequestPack(WebTarget webTarget) {
        return webTarget.request(MediaType.APPLICATION_JSON);
    }

    private void queryParamsPack(final WebTarget webTarget, Map<String, Object> queryParams) {
        for (Map.Entry<String, Object> entry : queryParams.entrySet()) {
            webTarget.queryParam(entry.getKey(), entry.getValue());
        }
    }

    private Response handleResponse(final Response response, final Response.Status... expectedStatus) {
        final List<Integer> expectedStatusCodes = getExpectedStatusCodes(expectedStatus);
        if (expectedStatusCodes.contains(response.getStatus())) {
            return response;
        }
        throw new FedexErrorResponseException(response.getStatus(), response.getStringHeaders(), response.readEntity(String.class));
    }

    private List<Integer> getExpectedStatusCodes(final Response.Status... expectedStatus) {
        return Arrays.stream(expectedStatus).map(Response.Status::getStatusCode).collect(Collectors.toList());
    }

    private Response invokeResponseCallable(final Callable<Response> responseCallable) {
        RetryPolicy<Response> retryPolicy = new RetryPolicy<Response>()
                .withMaxRetries(3) // 最大重试次数
                .withDelay(Duration.ofSeconds(2)) // 每次重试之间的延迟时间
                .handle(Exception.class) // 处理所有异常
                .onRetry(e -> {
                    System.out.println("Retry attempt #" + e.getAttemptCount() + " due to: " + e.getLastFailure().getMessage());
                });
        return Failsafe.with(retryPolicy).get(responseCallable::call);
    }
}
